Day 28 為使用系統的人寫了 docs/USAGE.md,並用測試確保環境變數、CLI 參數與程式一致。但要把這套系統接進自己的工具(例如需求管理平台、CI 腳本、聊天機器人)的開發者,需要的是另一種文件:
FastAPI 已經自動產生了 /docs 的互動式文件,今天在它的基礎上,匯出可以交給其他工具的 OpenAPI 規格、寫一份人看的 API 參考,並提供 Python 與 curl 兩種範例。和 Day 28 一樣,這些文件都要能自動檢查是否與程式一致。
第 4 週:發佈與開源
讀完這篇後,你會完成:
/api/v1/info
src/export_openapi.py:產生 docs/api/openapi.json,--check 可在 CI 檢查是否過時docs/API.md:17 個端點、認證流程、錯誤格式、約束與結果格式examples/python_client.py、examples/curl_examples.sh:從註冊到登出的完整流程tests/test_day29_api_docs.py:6 個測試,包含「API 文件與 OpenAPI 一一對應」與「範例程式真的能跑」寫文件的第一步,是把 FastAPI 產生的端點清單完整列一次。這一列就發現了三個不一致:
| 問題 | 現況 |
|---|---|
| 版本號有兩個 | FastAPI(version="2.0.0"),但 /、/api/v1/health、/api/v1/info 回傳 "1.0.0";CLI 的 --version 又是另一個值 |
| 分類標籤混用語言 | 認證相關是英文 authentication,其他是中文;Day 13 的三個免登入端點分在「衝突檢測」與「衝突檢測 (舊版)」兩類 |
/api/v1/info 過時 |
端點清單只有 Day 13 的免登入端點,沒有 Day 16 的 /api/v1/detect、Day 17 的 /refresh |
這些問題在 /docs 頁面上看得一清二楚,對串接的開發者來說也最困惑:到底該看哪個版本號?要用哪個檢測端點?所以先修 API,再寫文件。
src/__init__.py:__version__(唯一的版本來源)
├─ src/api_main.py:FastAPI(version=…)、/、/api/v1/health、/api/v1/info
└─ src/cli.py:--version
app.openapi()(FastAPI 依程式自動產生)
↓ python3 -m src.export_openapi
docs/api/openapi.json ← 交給 Postman、Swagger Editor、openapi-generator
docs/API.md ← 給人讀的參考
examples/python_client.py ← 可以 import 的範例函數
examples/curl_examples.sh ← 不寫程式也能試
tests/test_day29_api_docs.py
├─ openapi.json == app.openapi()
├─ API.md 的端點 == OpenAPI 的端點
├─ python_client.run_demo() 用 TestClient 跑完整流程
└─ 所有地方的版本號 == __version__
src/export_openapi.py、docs/API.md、docs/api/openapi.json、examples/(新增);src/__init__.py、src/api_main.py、src/api_auth.py、src/cli.py、docs/USAGE.md(修改)__version__ 發佈 1.0.0專案結構變化:
srs-review-agent/
├── src/
│ ├── __init__.py ← 修改:__version__
│ ├── export_openapi.py ← 【新增】
│ ├── api_main.py ← 修改:版本、標籤、/info
│ ├── api_auth.py ← 修改:標籤
│ └── cli.py ← 修改:版本改從 src 讀取
├── docs/
│ ├── API.md ← 【新增】
│ ├── api/openapi.json ← 【新增】
│ └── USAGE.md ← 修改:補上 API.md 連結
├── examples/
│ ├── python_client.py ← 【新增】
│ └── curl_examples.sh ← 【新增】
└── tests/
└── test_day29_api_docs.py ← 【新增】6 個測試
今天不需要新的套件(範例使用的 httpx 已在 requirements.txt)。
修改 src/__init__.py:
"""SRS Review Agent"""
# 單一版本來源:API(/、/api/v1/health、/api/v1/info、OpenAPI)與 CLI(--version)共用(Day 29)
__version__ = "0.29.0"
src/api_main.py 與 src/cli.py 都改成 from src import __version__:
app = FastAPI(
title="SRS Review Agent API",
description="Software Requirements Specification 自動審查系統",
version=__version__,
)
/、/api/v1/health、/api/v1/info 回傳的 "version" 也全部改成 __version__。今天的版本號是 0.29.0;明天發佈時只要改這一行,所有地方就一起變成 1.0.0。
分類標籤統一為中文:src/api_auth.py 的路由改成 tags=["認證與任務"],Day 13 的三個免登入端點統一為「衝突檢測(免登入)」。/api/v1/info 的端點清單補上 /api/v1/detect、/api/v1/auth/jobs、/api/v1/auth/login、/api/v1/auth/refresh 等目前主要使用的端點。
建立 src/export_openapi.py:
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "docs" / "api" / "openapi.json"
def current_spec() -> dict:
from src.api_main import app
return app.openapi()
def render(spec: dict) -> str:
return json.dumps(spec, ensure_ascii=False, indent=2) + "\n"
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="匯出 OpenAPI 規格")
parser.add_argument("--out", default=str(DEFAULT_OUT))
parser.add_argument("--check", action="store_true", help="檔案與程式不一致時結束碼為 1")
args = parser.parse_args(argv)
content = render(current_spec())
out = Path(args.out)
if args.check:
if not out.exists() or out.read_text(encoding="utf-8") != content:
print(f"{out} 與程式不一致,請執行 python3 -m src.export_openapi", file=sys.stderr)
return 1
return 0
...
為什麼要把規格存成檔案,而不是請大家看 /docs 就好?
openapi-generator 能據此產生 TypeScript、Java 等語言的 clientopenapi.json 就會跟著變,code review 時一眼就看得到ensure_ascii=False 讓中文說明保持可讀,indent=2 讓 diff 只顯示真正改動的行。--check 讓 CI 可以確認有人改了 API 卻忘了重新匯出。
docs/API.md 不是把 OpenAPI 再抄一次,而是補上 OpenAPI 表達不清楚的部分:
/refresh → 登出,以及「登入用表單格式,送 JSON 會得到 422」這種最常卡住的細節{"error", "code", "detail", "message", "timestamp"},以及每個狀態碼的常見原因{id, text} 兩種都接受(Day 13、16)req_id_1 == req_id_2 代表自我矛盾(Day 21);verified 不代表一定正確每個端點用固定的標題格式:
#### `POST /api/v1/detect`
提交檢測任務,立即回傳 `job_id`,檢測在背景執行,結果寫入資料庫。
- 標頭:`Authorization: Bearer <access_token>`
- 請求:`{"constraints": [...]}`(見約束格式)
- 回應:`{"job_id": "e5b21b64-...", "status": "queued", "message": "檢測任務已提交"}`
#### `方法 路徑` 這個格式是給測試讀的:測試用它抓出文件中的所有端點,和 OpenAPI 比對。
Day 13 的免登入端點也列在文件裡,但特別標註「正式部署時應關閉或加上認證:任何人都能列出所有免登入任務」。文件不只說明怎麼用,也要說明哪些不該用。
建立 examples/python_client.py,每個步驟都是一個獨立的函數:
class APIError(RuntimeError):
"""API 回傳錯誤;message 取自回應的 detail 欄位(Day 13 統一的錯誤格式)"""
def __init__(self, response: httpx.Response):
try:
detail = response.json().get("detail")
except ValueError:
detail = response.text
super().__init__(f"HTTP {response.status_code}:{detail}")
self.status_code = response.status_code
def login(client: httpx.Client, email: str, password: str) -> Dict:
"""登入要用表單格式(OAuth2 password flow,Day 15);返回 access_token 與 refresh_token"""
return _check(client.post("/api/v1/auth/login", data={"username": email, "password": password}))
def wait_for_job(client: httpx.Client, token: str, job_id: str,
interval: float = 1.0, timeout: float = 600.0) -> Dict:
"""每 interval 秒輪詢一次,直到 completed / failed(與前端 DetectPage 相同,Day 14)"""
deadline = time.monotonic() + timeout
while True:
job = _check(client.get(f"/api/v1/auth/jobs/{job_id}",
headers={"Authorization": f"Bearer {token}"}))
if job["status"] in ("completed", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(f"任務 {job_id} 在 {timeout} 秒內沒有完成")
time.sleep(interval)
所有函數的第一個參數都是 httpx.Client,而不是寫死的網址。這個設計有兩個目的:
login() 和 submit() 串進自己的程式TestClient(它本身就是 httpx.Client 的子類別),不必真的啟動伺服器,範例程式就能在每次測試時被執行run_demo() 把所有步驟串起來:註冊 → 登入 → 提交 → 輪詢 → 列出衝突 → 刷新令牌 → 登出。wait_for_job() 有逾時,login() 的 docstring 寫明了表單格式,範例本身就是文件。
建立 examples/curl_examples.sh,給不寫 Python 的讀者:
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8000}"
json() { python3 -c "import sys, json; print(json.load(sys.stdin)$1)"; }
echo "== 3. 登入(表單格式)"
LOGIN=$(curl -sf -X POST "$BASE_URL/api/v1/auth/login" -d "username=$EMAIL&password=$PASSWORD")
TOKEN=$(echo "$LOGIN" | json "['access_token']")
REFRESH=$(echo "$LOGIN" | json "['refresh_token']")
只依賴 curl 與 python3(不需要另外安裝 jq)。set -euo pipefail 加上 curl -sf,任何一步失敗腳本就停下來,不會帶著空的 token 繼續往下跑。BASE_URL 可以覆寫,指向 Day 26 Compose 的 nginx(:8080)也能用。
cd srs-review-agent
python3 -m pytest tests/test_day29_api_docs.py -v
tests/test_day29_api_docs.py::test_openapi_file_up_to_date PASSED [ 16%]
tests/test_day29_api_docs.py::test_api_doc_matches_spec PASSED [ 33%]
tests/test_day29_api_docs.py::test_python_client_example PASSED [ 50%]
tests/test_day29_api_docs.py::test_python_client_error_message PASSED [ 66%]
tests/test_day29_api_docs.py::test_single_version_source PASSED [ 83%]
tests/test_day29_api_docs.py::test_curl_script_syntax PASSED [100%]
============================== 6 passed in 0.51s ===============================
文件與 OpenAPI 的比對:
def test_api_doc_matches_spec():
"""測試 2:API.md 的端點與 OpenAPI 一一對應"""
spec, doc = _spec_operations(), _documented_operations()
assert doc == spec, f"文件缺少:{sorted(spec - doc)};文件多出:{sorted(doc - spec)}"
之後有人新增端點卻沒寫進 API.md,或刪了端點卻沒從文件拿掉,這個測試都會失敗,訊息直接指出是哪一個。
test_single_version_source 同時檢查 OpenAPI、三個 API 端點與 CLI 的版本號,CLI 的部分是真的開一個子行程執行 python3 -m src.cli --version。
Day 28 的 test_referenced_files_exist 這時也發揮了作用:USAGE.md 加回「端點說明見 docs/API.md」的連結後,它確認這個檔案存在。
python3 -m src.export_openapi
已寫入 docs/api/openapi.json(17 個路徑,版本 0.29.0)
python3 -m src.export_openapi --check
# docs/api/openapi.json 與程式一致
uvicorn src.api_main:app --port 8000
python3 examples/python_client.py
✅ 已登入:demo_8e9fc83c@example.com
✅ 已提交:b3c1686a-fe82-4e36-af11-77daf272fc00
✅ 完成:發現 1 個衝突
[高] REQ-1 ↔ REQ-2:檢測到 多用戶 vs 單用戶
✅ 已刷新令牌
✅ 已登出
bash examples/curl_examples.sh
== 1. 健康檢查
healthy
== 2. 註冊
curl_1790920845@example.com
== 3. 登入(表單格式)
access token:eyJhbGciOiJIUzI1NiIs...
== 4. 提交檢測
job_id:eb52566f-e7be-446e-be9a-e9fdc6660a75
== 5. 輪詢結果
狀態:completed
[高] REQ-1 ↔ REQ-2:檢測到 多用戶 vs 單用戶
== 6. 刷新令牌
新的 access token:eyJhbGciOiJIUzI1NiIs...
== 7. 登出,舊令牌失效
已登出
登出後呼叫 /me:401
最後一行重現了 Day 17 的成果:登出後舊令牌立即失效。
/api/v1/...,但目前沒有 v2,也沒有棄用政策。真的要改動回應格式時,需要決定是開 /api/v2 還是在 v1 中相容summary 由函數名稱產生(例如「Detect Conflicts」),中文說明寫在 docstring 與 API.md。要完全中文化,需要在每個端點加上 summary= 參數python_client.py 的流程很短,不會遇到 15 分鐘的效期。長時間執行的程式要參考 Day 17 前端的 authRequest():遇到 401 先刷新再重試git add src/__init__.py src/export_openapi.py src/api_main.py src/api_auth.py src/cli.py \
docs/API.md docs/api/openapi.json docs/USAGE.md examples/ tests/test_day29_api_docs.py
git commit -m "Day 29: OpenAPI 匯出、API 參考文件、Python 與 curl 範例"
最後一天 Day 30。程式、文件、範例、容器都準備好了,剩下讓別人能拿去用:README、授權、貢獻指南、版本紀錄、pip install 的套件設定,並把版本號改成 1.0.0。最後回顧這 30 天:哪些決定是對的、哪些數字比預期差,以及這個系統還不能做什麼。